#
# This file is part of Contentbus

# Contentbus is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# Contentbus is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.

# You should have received a copy of the GNU General Public License
# along with Contentbus.  If not, see <http://www.gnu.org/licenses/>.

"""Bus main classes"""

import uuid
from collections import namedtuple

from twisted.internet.defer import succeed

from contentbus.match.matcher import ObjectMatcher


class ClientAlreadyConnected(Exception):
    """Raised when a L{Client} is already connected to the L{Bus}."""


class ClientNotConnected(Exception):
    """Raised when trying to disconnect a L{Client} which is not connected."""


class CallbackNotRegistered(Exception):
    """
    Raised when trying to remove a L{NotifyCallback} which is not registered.
    """


# The Message object just holds sender and object
Message = namedtuple("Message", ("sender", "object"))


class NotifyCallback(object):
    """
    Holds a callback function for a L{Client} with an L{ObjectMatcher}.

    The L{NotifyCallback} can be called with a L{Message} and runs the
    C{callback} if the L{ObjectMatcher} matches C{Message.object}.

    @param matcher: the L{ObjectMatcher} objects are matched against.
    @param callback: the C{callback} called with matched objects.
    @param client: the C{Client} to notify.
    """

    def __init__(self, matcher, callback, client):
        self.__matcher = matcher
        self.__callback = callback
        self.__client = client

    @property
    def matcher(self):
        return self.__matcher

    @property
    def client(self):
        return self.__client

    def __call__(self, message):
        if self.__matcher.matches(message.object):
            self.__callback(message)

    def __eq__(self, other):
        return (self.__matcher, self.__callback, self.__client) == (
            other.__matcher, other.__callback, other.__client)


class Client(object):
    """
    A client connection to a L{Bus}.

    @param bus: the L{Bus} to connect to.
    """

    def __init__(self,  bus):
        self.__bus = bus
        self.__uuid = uuid.uuid1()
        self.__callbacks = set()

    @property
    def id(self):
        """Client unique identifier."""
        return "cli:%s" % str(self.__uuid)

    def __del__(self):
        try:
            self.disconnect()
        except ClientNotConnected:
            pass

    def __eq__(self, other):
        return (self.__bus, self.__uuid) == (other.__bus, other.__uuid)

    def connect(self):
        """Connect to the L{Bus}."""
        self.__bus.connect(self)

    def disconnect(self):
        """
        Disconnect from the L{Bus}.

        All registered C{callback}s for L{Client} will be unsubscribed.
        """
        try:
            self.__bus.disconnect(self)
        finally:
            self.__callbacks.clear()

    def subscribe(self, callback, object_type, match_fun=None,
                  **match_params):
        """
        Subscribe a C{callback} for an object type.

        @param callback: a callable which will be called with the object
           matching the criteria.
        @param object_type: the type of the objects to match.
        @param match_fun: an optional function to match objects against.
        @param match_params: optional match parameters.
        @return: a L{NotifyCallback}.

        Based on the arguments provided, one of these matches is performed:

         - if C{match_fun} is provided, it must have the following signature::

            match_fun(object) -> bool

           and will be called with the actual C{object} to determine the match.

         - if C{match_params} are provided, the C{object} is matched against
           hem.

         - all objects of C{object_type} are a match.
        """
        matcher = ObjectMatcher(object_type, match_fun, **match_params)
        notify_callback = NotifyCallback(matcher, callback, self)

        self.__bus.subscribe(notify_callback)
        self.__callbacks.add(notify_callback)
        return notify_callback

    def unsubscribe(self, notify_callback):
        """Unsubscribe a C{NotifyCallback}."""
        self.__bus.unsubscribe(notify_callback)
        self.__callbacks.remove(notify_callback)

    def send(self, obj):
        """
        Send an C{object} on the L{Bus}.

        @param obj: the C{object} to send.
        @return: a L{Deferred} returned by the bus.
        """
        return self.__bus.send(Message(sender=self.id, object=obj))


class Bus(object):
    """Content-based Bus."""

    def __init__(self):
        self.__callbacks = {}  # {type: set([NotifyCallback, ...)]}
        self.__clients = {}  # {Client: set([NotifyCallback, ...)]}

    def connect(self, client):
        """
        Connect a L{Client} to the L{Bus}.

        @param client: the L{Client} to connect.
        """
        if client in self.__clients:
            raise ClientAlreadyConnected(
                "Client '%s' already connected" % client.id)
        self.__clients[client] = set()

    def __check_client_registered(self, client):
        if client not in self.__clients:
            raise ClientNotConnected(
                "Client '%s' not connected" % client.id)

    def disconnect(self, client):
        """
        Disconnect a L{Client} from L{Bus}.

        @param client: the L{Client} to disconnect.
        """
        self.__check_client_registered(client)

        for callback in self.__clients[client]:
            self.unsubscribe(callback)
        del self.__clients[client]

    def subscribe(self, callback):
        """
        Subscribe a L{NotifyCallback}.

        @param callback: the L{NotifyCallback} to register.
        """
        client = callback.client
        obj_type = callback.matcher.object_type

        self.__check_client_registered(client)

        if obj_type not in self.__callbacks:
            self.__callbacks[obj_type] = set()
        self.__callbacks[obj_type].add(callback)
        self.__clients[client].add(callback)

    def unsubscribe(self, callback):
        """
        Unsubscribe a C{NotifyCallback}.

        @param callback: the callback to remove.
        """
        client = callback.client
        obj_type = callback.matcher.object_type

        self.__check_client_registered(client)

        try:
            self.__callbacks[obj_type].remove(callback)
        except (KeyError, ValueError):
            raise CallbackNotRegistered("NotifyCallback not registered")

        # remove empty lists for object types
        if not self.__callbacks[obj_type]:
            del self.__callbacks[obj_type]
        self.__clients[client].remove(callback)

    def __wrap_callback(self, callback):
        def wrap(result):
            callback(result)
            return result
        return wrap

    def send(self, message):
        """
        Send a L{Message} on the L{Bus}.

        @param message: the {Message} to send.
        @return: a L{Deferred}.
        """
        deferred = succeed(message)

        for callback in self.__callbacks.get(type(message.object), []):
            deferred.addCallback(self.__wrap_callback(callback))

        return deferred

    def disconnect_all(self):
        """Disconnect everything from the L{Bus}."""
        self.__callbacks.clear()
        self.__clients.clear()

    def __del__(self):
        self.disconnect_all()
